![[為你自己寫 Vue Component] AtomicBreadcrumb](https://ithelp.ithome.com.tw/upload/images/20240917/201204841TkP7kY1au.png)
麵包屑導覽列(Breadcrumb)是一種常見的導航元件,其名稱源自格林童話《糖果屋》,故事中兩兄妹用麵包屑標記回家的路徑。在網站中,麵包屑導覽列起到了類似的指路功能,用於顯示使用者在網站中的位置,並提供返回上層頁面的快捷方式。

麵包屑導覽列具有導航、指路的功能,妥善應用對於 SEO 會有一些幫助。因此,設計上正確使用語意化標籤非常重要。
為了讓元件不僅是 UI 呈現,還能同時能滿足 SEO 需求,建議實作時使用的標籤如下:
<nav> 標籤來包裹會比 <div> 更好。<ol> 標籤來表示。<a> 標籤來表示。
<ol>標籤的原意是 Ordered List,用於表示有順序性的列表。而<ul>標籤的原意是 Unordered List,用於表示沒有順序性(順序不重要)的列表。
在開始實作前,我們先研究各個 UI Library 的 Breadcrumb 元件是如何設計的。
Element Plus

<template>
  <ElBreadcrumb separator="/">
    <ElBreadcrumbItem :to="{ path: '/' }">homepage</ElBreadcrumbItem>
    <ElBreadcrumbItem>
      <a href="/">promotion management</a>
    </ElBreadcrumbItem>
    <ElBreadcrumbItem>promotion list</ElBreadcrumbItem>
    <ElBreadcrumbItem>promotion detail</ElBreadcrumbItem>
  </ElBreadcrumb>
</template>
Element Plus 提供給開發人員 <ElBreadcrumb> 與 <ElBreadcrumbItem> 兩個元件,讓使用者組合出麵包屑導覽列。在 <ElBreadcrumb> 上可以設定分隔符號,而 <ElBreadcrumbItem> 則是用來表示每個節點的資訊。
<ElBreadcrumbItem> 接受 to 這個 prop 來設定連結,如果沒有設定 to,則會當作一般文字處理。
上面的範例程式碼渲染後的結果如下:

有點可惜的是,從結果看來沒有很好地使用 HTML 的語義化標籤,即使在 <ElBreadcrumbItem> 上傳入 to 這個 prop,也沒有使用 <a> 標籤來表示。
Vuetify

<template>
  <VBreadcrumbs :items="items" divider="-" />
</template>
const items = [
  { title: 'Dashboard', disabled: false, href: 'breadcrumbs_dashboard' },
  { title: 'Link 1', disabled: false, href: 'breadcrumbs_link_1' },
  { title: 'Link 2', disabled: true, href: 'breadcrumbs_link_2' },
]
Vuetify 透過 items 這個屬性來設定麵包屑導覽列的資料。這樣的設計讓我們只需要一個元件搭配正確的資料就可以完成需求。整體而言,乾淨簡單且好用,但如果想要更細緻地控制一些細節,可能會比較困難。
上面的範例程式碼渲染後的結果如下:

Vuetify 的 <VBreadcrumbs> 在結果中比較好地使用了 HTML 的語義化標籤。有一點美中不足的是,Vuetify 預設選用的 list 標籤為 <ul> 而不是 <ol>,不過這部分可以使用 tag 這個 prop 修改,所以也不算是大問題。
<template>
  <VBreadcrumbs tag="ol" :items="items" divider="-" />
</template>
Element Plus 與 Vuetify 在元件的使用上採取了完全不同的策略。Element Plus 提供了兩個元件讓開發人員自行組合,而 Vuetify 則提供了一個元件,讓開發人員只需要傳入正確的資料就可以完成需求。
拆成多個元件讓開發人員自行組合的設計,靈活性高、控制粒度細,可以更自由地客製化麵包屑導覽列;但這樣高的靈活性同時也使得模板結構變得更複雜,如果沒有適時的抽象,可能會導致模板過於冗長,增加後續維護成本。
綜合以上,我們的麵包屑導覽列希望可以支援下列功能:
items 屬性接收麵包屑導覽列的資料。separator 屬性設定分隔符號。首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:
| 屬性 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| items | { to: RouteLocationRaw; label: string }[] | 麵包屑導覽列的資料 | |
| separator | string,Component | / | 分隔符號 | 
export type BreadcrumbItem = {
  to: RouteLocationRaw;
  label: string;
};
type AtomicBreadcrumbProps = {
  items: BreadcrumbItem[];
  separator?: string | Component;
};
withDefaults(defineProps<AtomicBreadcrumbProps>(), {
  separator: '/',
});
模板部分非常簡單,我們只要確保 HTML 的語意化標籤正確使用即可。
<template>
  <nav class="atomic-breadcrumb">
    <ol class="atomic-breadcrumb__container">
      <li 
        v-for="(item, index) in items"
        :key="index"
        class="atomic-breadcrumb__item"
      >
        <AtomicLink
          class="atomic-breadcrumb__link"
          :to="item.to"
        >
          {{ item.label }}
        </AtomicLink>
        <span
          v-if="index < items.length - 1"
          class="atomic-breadcrumb__separator"
        >
          <template v-if="isString(separator)">
            {{ separator }}
          </template>
          <template v-else>
            <component :is="separator" />
          </template>
        </span>
      </li>
    </ol>
  </nav>
</template>
路由元件部分我們使用之前做好的 <AtomicLink>,這樣一來就同時支援了內部連結與外部連結,以及 Smart Prefetching 的功能!
這裡可以再加入一些彈性,如果使用者沒有傳入 to 這個屬性,我們就不要使用 <AtomicLink> 這個元件,而是直接渲染文字。這個部分我們可以使用動態元件來達成。
<component
  :is="isNullOrUndefined(item.to) ? 'span' : AtomicLink"
  class="atomic-breadcrumb__link"
  :to="item.to"
>
  {{ item.label }}
</component>
這樣我們就完成了最簡單的 <AtomicBreadcrumb> 元件了!
前面提到 Vuetify 的設計讓使用者只需要一個元件搭配正確的資料就可以輕鬆完成需求。但比起 Element Plus 的做法,Vuetify 版本看起來少了一些客製化的彈性。
如果仔細翻找 Vuetify 的文件,我們會發現其實 <VBreadcrumbs> 也提供了 item 這個 slot,並且提供 <VBreadcrumbsItem> 這個元件給開發人員自行組合使用,很好地覆蓋了簡易使用與高度客製化的需求。
<template>
  <VBreadcrumbs :items="items" divider="-">
    <template #item="{ item }">
      <VBreadcrumbsItem :to="item.to">
        {{ item.title }}
      </VBreadcrumbsItem>
    </template>
  </VBreadcrumbs>
</template>
如果我們的 <AtomicBreadcrumb> 元件也能像這樣兼具簡易使用與高度客製化的需求,那就太好了!以下是我們要挑戰的進階需求:
item 前面加上一個 icon,甚至只顯示 icon。item 的內容。為了在每個 item 前面加上一個 icon,我們需要擴充 BreadcrumbItem 的介面。
| 屬性 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| icon | Component | icon 元件 | |
| iconOnly | boolean | 是否只顯示 icon(僅在 icon 存在時有效) | 
type BreadcrumbItem = {
  icon?: Component;
  iconOnly?: boolean;
};
模板部分做一點點修改,如果有傳入 icon 時,才把需要的結構渲染出來。另外,iconOnly 必須要在 icon 有使用時才會有效果。
<component
  :is="isNullOrUndefined(item.to) ? 'span' : AtomicLink"
  class="atomic-breadcrumb__link"
  :to="item.to"
>
  <span
    v-if="item.icon"
    class="atomic-breadcrumb__icon"
  >
    <component :is="item.icon" />
  </span>
  <span
    class="atomic-breadcrumb__label"
    :class="{ 
      'atomic-breadcrumb__label--sr-only': item.icon && item.iconOnly 
    }"
  >
    {{ item.label }}
  </span>
</component>
說明一下 atomic-breadcrumb__label--sr-only 這個 class。
@mixin sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}
.atomic-breadcrumb {
  &__label--sr-only {
    @include sr-only;
  }
}
這裡不顯示 label 的做法不是移除結構,也不是使用 display: none 讓結構不被渲染,而是加上了 atomic-breadcrumb__label--sr-only 這個 class。sr-only 是 Screen Reader Only 的縮寫,這個 class 會讓元素在畫面上看起來像是被隱藏,但在螢幕閱讀器中會被正常讀取。
接著,named slot 的部分跟之前做 <AtomicButton> 時一樣,我們可以將原本的內容變成預設內容,如未使用 item 這個 named slot,則會渲染預設的 UI。
<component
  :is="isNullOrUndefined(item.to) ? 'span' : AtomicLink"
  class="atomic-breadcrumb__link"
  :to="item.to"
>
  <slot
    :index="index"
    :item="item"
    name="item"
  >
    <!-- 原本的內容 -->
  </slot>
</component>
最後,完成的 <AtomicBreadcrumb> 渲染結果如下:

渲染出來的 HTML 結構如下:

麵包屑導覽列的無障礙性要求包括:
<nav> 標籤並使用 aria-label 或 aria-labelledby 作為說明。<ol> 標籤來表示有序列表。aria-hidden="true" 來隱藏分隔符號。如果分隔符號是使用 CSS 加上去的,則不用。第一點在需求分析中已經提到,而第二點因為 <RouterLink>(<AtomicLink>)這個元件,在遇到連結與當前頁面相同時會自動加上 aria-current="page",所以不需要特別處理。
剩下第一點與第四點,我們只要在指定的地方加上 aria-label 與 aria-hidden 就可以了。
<template>
  <nav
    aria-label="Breadcrumb"
    class="atomic-breadcrumb"
  >
    <!-- 略 -->
  </nav>
</template>
儘管 <AtomicBreadcrumb> 是一個簡單的元件,我們在分析與實作過程中理解了如何應用 props 與 named slot 的搭配,設計出兼具簡易使用與高度客製化的元件。
除此之外,我們也特別關注了應該使用 <nav> 與 <ol> 來組成麵包屑導覽列的結構,並且在無障礙性上做了一些處理。這些看似微不足道的小調整,卻能改善 SEO,也能照顧到更多不同的使用者,何樂而不為呢?
<AtomicBreadcrumb> 原始碼:AtomicBreadcrumb.vue
直接使用元件,對於、等標籤的用法,還有屬性等等,都沒有探究...
更真的沒再多看props,哈哈~
可以嘗試開始有意識地挑選標籤的使用 👍
好奇大大怎麼看待選擇 iconOnly 而不是 isIconOnly 呢?
因最近發現很多套件或框架的 bool 值都不一定是符合常見的命名慣例,例如 nuxt 的 immediate
ummm…我曾經思考過這個問題但我沒有很明確的答案,我心中對於這塊的標準還很浮動。
不過大致會有幾個依據,像是 options(config)我可能就不會選用 isXXX 這個命名慣例,除了你舉出的 Nuxt 的 useFetch 外,像是 Vue 的 watch 的 immediate 跟 deep 就屬於這一類。
另外像是綁定到元件上的 props 我不會用這個命名慣例,例如:
<!-- ✅ 我會選擇這個 -->
<AtomicCheckbox indeterminate />
<!-- 🤔 ummm…,也不是不行 -->
<AtomicCheckbox is-indeterminate />
下列這個時候我可能會使用你提到的命名慣例:
const isError = ref(false)
const isLoading = computed(() => { ... })
const isMounted = useMounted()
不過也不是 100% 會這樣做,但有意識到的時候會盡量遵守,標準還很浮動。
如果你有一些建議或是參考依據可以跟我分享嗎?我也很好奇你會怎麼拿捏這個慣例!
另外我發現 <AtomicAvatar> 裡面的 error 就是我 miss 掉的例外,我應該會把它改成 isError。
我自己也很浮動😂 想找找看有沒有什麼說法能說服自己。
之前開發時需要在 function 的 options 加上 immediate,就在想要不要改成 isImmediate,但可能 immediate 看習慣了,覺得加上 is 很不順眼